let url = require("url");
let MemoryCache = require("./memory-cache");

let t = {
    ms: 1,
    second: 1000,
    minute: 60000,
    hour: 3600000,
    day: 3600000 * 24,
    week: 3600000 * 24 * 7,
    month: 3600000 * 24 * 30,
};

let instances = [];

/**
 * Does a === b
 * @param {any} a
 * @returns {function(any): boolean}
 */
let matches = function (a) {
    return function (b) {
        return a === b;
    };
};

/**
 * Does a!==b
 * @param {any} a
 * @returns {function(any): boolean}
 */
let doesntMatch = function (a) {
    return function (b) {
        return !matches(a)(b);
    };
};

/**
 * Get log duration
 * @param {number} d Time in ms
 * @param {string} prefix Prefix for log
 * @returns {string} Coloured log string
 */
let logDuration = function (d, prefix) {
    let str = d > 1000 ? (d / 1000).toFixed(2) + "sec" : d + "ms";
    return "\x1b[33m- " + (prefix ? prefix + " " : "") + str + "\x1b[0m";
};

/**
 * Get safe headers
 * @param {Object} res Express response object
 * @returns {Object}
 */
function getSafeHeaders(res) {
    return res.getHeaders ? res.getHeaders() : res._headers;
}

/** Constructor for ApiCache instance */
function ApiCache() {
    let memCache = new MemoryCache();

    let globalOptions = {
        debug: false,
        defaultDuration: 3600000,
        enabled: true,
        appendKey: [],
        jsonp: false,
        redisClient: false,
        headerBlacklist: [],
        statusCodes: {
            include: [],
            exclude: [],
        },
        events: {
            expire: undefined,
        },
        headers: {
            // 'cache-control':  'no-cache' // example of header overwrite
        },
        trackPerformance: false,
        respectCacheControl: false,
    };

    let middlewareOptions = [];
    let instance = this;
    let index = null;
    let timers = {};
    let performanceArray = []; // for tracking cache hit rate

    instances.push(this);
    this.id = instances.length;

    /**
     * Logs a message to the console if the `DEBUG` environment variable is set.
     * @param {string} a The first argument to log.
     * @param {string} b The second argument to log.
     * @param {string} c The third argument to log.
     * @param {string} d The fourth argument to log, and so on... (optional)
     *
     * Generated by Trelent
     */
    function debug(a, b, c, d) {
        let arr = ["\x1b[36m[apicache]\x1b[0m", a, b, c, d].filter(function (arg) {
            return arg !== undefined;
        });
        let debugEnv = process.env.DEBUG && process.env.DEBUG.split(",").indexOf("apicache") !== -1;

        return (globalOptions.debug || debugEnv) && console.log.apply(null, arr);
    }

    /**
     * Returns true if the given request and response should be logged.
     * @param {Object} request The HTTP request object.
     * @param {Object} response The HTTP response object.
     * @param {function(Object, Object):boolean} toggle
     * @returns {boolean}
     */
    function shouldCacheResponse(request, response, toggle) {
        let opt = globalOptions;
        let codes = opt.statusCodes;

        if (!response) {
            return false;
        }

        if (toggle && !toggle(request, response)) {
            return false;
        }

        if (codes.exclude && codes.exclude.length && codes.exclude.indexOf(response.statusCode) !== -1) {
            return false;
        }
        if (codes.include && codes.include.length && codes.include.indexOf(response.statusCode) === -1) {
            return false;
        }

        return true;
    }

    /**
     * Add key to index array
     * @param {string} key Key to add
     * @param {Object} req Express request object
     */
    function addIndexEntries(key, req) {
        let groupName = req.apicacheGroup;

        if (groupName) {
            debug("group detected \"" + groupName + "\"");
            let group = (index.groups[groupName] = index.groups[groupName] || []);
            group.unshift(key);
        }

        index.all.unshift(key);
    }

    /**
     * Returns a new object containing only the whitelisted headers.
     * @param {Object} headers The original object of header names and
     * values.
     * @param {string[]} globalOptions.headerWhitelist An array of
     * strings representing the whitelisted header names to keep in the
     * output object.
     *
     * Generated by Trelent
     */
    function filterBlacklistedHeaders(headers) {
        return Object.keys(headers)
            .filter(function (key) {
                return globalOptions.headerBlacklist.indexOf(key) === -1;
            })
            .reduce(function (acc, header) {
                acc[header] = headers[header];
                return acc;
            }, {});
    }

    /**
     * Create a cache object
     * @param {Object} headers The response headers to filter.
     * @returns {Object} A new object containing only the whitelisted
     * response headers.
     *
     * Generated by Trelent
     */
    function createCacheObject(status, headers, data, encoding) {
        return {
            status: status,
            headers: filterBlacklistedHeaders(headers),
            data: data,
            encoding: encoding,
            timestamp: new Date().getTime() / 1000, // seconds since epoch.  This is used to properly decrement max-age headers in cached responses.
        };
    }

    /**
     * Sets a cache value for the given key.
     * @param {string} key The cache key to set.
     * @param {any} value The cache value to set.
     * @param {number} duration How long in milliseconds the cached
     * response should be valid for (defaults to 1 hour).
     *
     * Generated by Trelent
     */
    function cacheResponse(key, value, duration) {
        let redis = globalOptions.redisClient;
        let expireCallback = globalOptions.events.expire;

        if (redis && redis.connected) {
            try {
                redis.hset(key, "response", JSON.stringify(value));
                redis.hset(key, "duration", duration);
                redis.expire(key, duration / 1000, expireCallback || function () {});
            } catch (err) {
                debug("[apicache] error in redis.hset()");
            }
        } else {
            memCache.add(key, value, duration, expireCallback);
        }

        // add automatic cache clearing from duration, includes max limit on setTimeout
        timers[key] = setTimeout(function () {
            instance.clear(key, true);
        }, Math.min(duration, 2147483647));
    }

    /**
     * Appends content to the response.
     * @param {Object} res Express response object
     * @param {(string|Buffer)} content The content to append.
     *
     * Generated by Trelent
     */
    function accumulateContent(res, content) {
        if (content) {
            if (typeof content == "string") {
                res._apicache.content = (res._apicache.content || "") + content;
            } else if (Buffer.isBuffer(content)) {
                let oldContent = res._apicache.content;

                if (typeof oldContent === "string") {
                    oldContent = !Buffer.from ? new Buffer(oldContent) : Buffer.from(oldContent);
                }

                if (!oldContent) {
                    oldContent = !Buffer.alloc ? new Buffer(0) : Buffer.alloc(0);
                }

                res._apicache.content = Buffer.concat(
                    [oldContent, content],
                    oldContent.length + content.length
                );
            } else {
                res._apicache.content = content;
            }
        }
    }

    /**
     * Monkeypatches the response object to add cache control headers
     * and create a cache object.
     * @param {Object} req Express request object
     * @param {Object} res Express response object
     * @param {function} next Function to call next
     * @param {string} key Key to add response as
     * @param {number} duration Time to cache response for
     * @param {string} strDuration Duration in string form
     * @param {function(Object, Object):boolean} toggle
     */
    function makeResponseCacheable(req, res, next, key, duration, strDuration, toggle) {
    // monkeypatch res.end to create cache object
        res._apicache = {
            write: res.write,
            writeHead: res.writeHead,
            end: res.end,
            cacheable: true,
            content: undefined,
        };

        // append header overwrites if applicable
        Object.keys(globalOptions.headers).forEach(function (name) {
            res.setHeader(name, globalOptions.headers[name]);
        });

        res.writeHead = function () {
            // add cache control headers
            if (!globalOptions.headers["cache-control"]) {
                if (shouldCacheResponse(req, res, toggle)) {
                    res.setHeader("cache-control", "max-age=" + (duration / 1000).toFixed(0));
                } else {
                    res.setHeader("cache-control", "no-cache, no-store, must-revalidate");
                }
            }

            res._apicache.headers = Object.assign({}, getSafeHeaders(res));
            return res._apicache.writeHead.apply(this, arguments);
        };

        // patch res.write
        res.write = function (content) {
            accumulateContent(res, content);
            return res._apicache.write.apply(this, arguments);
        };

        // patch res.end
        res.end = function (content, encoding) {
            if (shouldCacheResponse(req, res, toggle)) {
                accumulateContent(res, content);

                if (res._apicache.cacheable && res._apicache.content) {
                    addIndexEntries(key, req);
                    let headers = res._apicache.headers || getSafeHeaders(res);
                    let cacheObject = createCacheObject(
                        res.statusCode,
                        headers,
                        res._apicache.content,
                        encoding
                    );
                    cacheResponse(key, cacheObject, duration);

                    // display log entry
                    let elapsed = new Date() - req.apicacheTimer;
                    debug("adding cache entry for \"" + key + "\" @ " + strDuration, logDuration(elapsed));
                    debug("_apicache.headers: ", res._apicache.headers);
                    debug("res.getHeaders(): ", getSafeHeaders(res));
                    debug("cacheObject: ", cacheObject);
                }
            }

            return res._apicache.end.apply(this, arguments);
        };

        next();
    }

    /**
     * Send a cached response to client
     * @param {Request} request Express request object
     * @param {Response} response Express response object
     * @param {object} cacheObject Cache object to send
     * @param {function(Object, Object):boolean} toggle
     * @param {function} next Function to call next
     * @param {number} duration Not used
     * @returns {boolean|undefined} true if the request should be
     * cached, false otherwise. If undefined, defaults to true.
     */
    function sendCachedResponse(request, response, cacheObject, toggle, next, duration) {
        if (toggle && !toggle(request, response)) {
            return next();
        }

        let headers = getSafeHeaders(response);

        // Modified by @louislam, removed Cache-control, since I don't need client side cache!
        // Original Source: https://github.com/kwhitley/apicache/blob/0d5686cc21fad353c6dddee646288c2fca3e4f50/src/apicache.js#L254
        Object.assign(headers, filterBlacklistedHeaders(cacheObject.headers || {}));

        // only embed apicache headers when not in production environment
        if (process.env.NODE_ENV !== "production") {
            Object.assign(headers, {
                "apicache-store": globalOptions.redisClient ? "redis" : "memory",
                "apicache-version": "1.6.2-modified",
            });
        }

        // unstringify buffers
        let data = cacheObject.data;
        if (data && data.type === "Buffer") {
            data =
        typeof data.data === "number" ? new Buffer.alloc(data.data) : new Buffer.from(data.data);
        }

        // test Etag against If-None-Match for 304
        let cachedEtag = cacheObject.headers.etag;
        let requestEtag = request.headers["if-none-match"];

        if (requestEtag && cachedEtag === requestEtag) {
            response.writeHead(304, headers);
            return response.end();
        }

        response.writeHead(cacheObject.status || 200, headers);

        return response.end(data, cacheObject.encoding);
    }

    /** Sync caching options */
    function syncOptions() {
        for (let i in middlewareOptions) {
            Object.assign(middlewareOptions[i].options, globalOptions, middlewareOptions[i].localOptions);
        }
    }

    /**
     * Clear key from cache
     * @param {string} target Key to clear
     * @param {boolean} isAutomatic Is the key being cleared automatically
     * @returns {number}
     */
    this.clear = function (target, isAutomatic) {
        let group = index.groups[target];
        let redis = globalOptions.redisClient;

        if (group) {
            debug("clearing group \"" + target + "\"");

            group.forEach(function (key) {
                debug("clearing cached entry for \"" + key + "\"");
                clearTimeout(timers[key]);
                delete timers[key];
                if (!globalOptions.redisClient) {
                    memCache.delete(key);
                } else {
                    try {
                        redis.del(key);
                    } catch (err) {
                        console.log("[apicache] error in redis.del(\"" + key + "\")");
                    }
                }
                index.all = index.all.filter(doesntMatch(key));
            });

            delete index.groups[target];
        } else if (target) {
            debug("clearing " + (isAutomatic ? "expired" : "cached") + " entry for \"" + target + "\"");
            clearTimeout(timers[target]);
            delete timers[target];
            // clear actual cached entry
            if (!redis) {
                memCache.delete(target);
            } else {
                try {
                    redis.del(target);
                } catch (err) {
                    console.log("[apicache] error in redis.del(\"" + target + "\")");
                }
            }

            // remove from global index
            index.all = index.all.filter(doesntMatch(target));

            // remove target from each group that it may exist in
            Object.keys(index.groups).forEach(function (groupName) {
                index.groups[groupName] = index.groups[groupName].filter(doesntMatch(target));

                // delete group if now empty
                if (!index.groups[groupName].length) {
                    delete index.groups[groupName];
                }
            });
        } else {
            debug("clearing entire index");

            if (!redis) {
                memCache.clear();
            } else {
                // clear redis keys one by one from internal index to prevent clearing non-apicache entries
                index.all.forEach(function (key) {
                    clearTimeout(timers[key]);
                    delete timers[key];
                    try {
                        redis.del(key);
                    } catch (err) {
                        console.log("[apicache] error in redis.del(\"" + key + "\")");
                    }
                });
            }
            this.resetIndex();
        }

        return this.getIndex();
    };

    /**
     * Converts a duration string to an integer number of milliseconds.
     * @param {(string|number)} duration The string to convert.
     * @param {number} defaultDuration The default duration to return if
     * can't parse duration
     * @returns {number} The converted value in milliseconds, or the
     * defaultDuration if it can't be parsed.
     */
    function parseDuration(duration, defaultDuration) {
        if (typeof duration === "number") {
            return duration;
        }

        if (typeof duration === "string") {
            let split = duration.match(/^([\d\.,]+)\s?([a-zA-Z]+)$/);

            if (split.length === 3) {
                let len = parseFloat(split[1]);
                let unit = split[2].replace(/s$/i, "").toLowerCase();
                if (unit === "m") {
                    unit = "ms";
                }

                return (len || 1) * (t[unit] || 0);
            }
        }

        return defaultDuration;
    }

    /**
     * Parse duration
     * @param {(number|string)} duration
     * @returns {number} Duration parsed to a number
     */
    this.getDuration = function (duration) {
        return parseDuration(duration, globalOptions.defaultDuration);
    };

    /**
   * Return cache performance statistics (hit rate).  Suitable for
   * putting into a route:
   * <code>
   * app.get('/api/cache/performance', (req, res) => {
   *    res.json(apicache.getPerformance())
   * })
   * </code>
   * @returns {any[]}
   */
    this.getPerformance = function () {
        return performanceArray.map(function (p) {
            return p.report();
        });
    };

    /**
     * Get index of a group
     * @param {string} group 
     * @returns {number}
     */
    this.getIndex = function (group) {
        if (group) {
            return index.groups[group];
        } else {
            return index;
        }
    };

    /**
     * Express middleware
     * @param {(string|number)} strDuration Duration to cache responses
     * for.
     * @param {function(Object, Object):boolean} middlewareToggle 
     * @param {Object} localOptions Options for APICache
     * @returns 
     */
    this.middleware = function cache(strDuration, middlewareToggle, localOptions) {
        let duration = instance.getDuration(strDuration);
        let opt = {};

        middlewareOptions.push({
            options: opt,
        });

        let options = function (localOptions) {
            if (localOptions) {
                middlewareOptions.find(function (middleware) {
                    return middleware.options === opt;
                }).localOptions = localOptions;
            }

            syncOptions();

            return opt;
        };

        options(localOptions);

        /**
         * A Function for non tracking performance
         */
        function NOOPCachePerformance() {
            this.report = this.hit = this.miss = function () {}; // noop;
        }

        /**
         * A function for tracking and reporting hit rate.  These
         * statistics are returned by the getPerformance() call above.
         */
        function CachePerformance() {
            /**
             * Tracks the hit rate for the last 100 requests. If there
             * have been fewer than 100 requests, the hit rate just
             * considers the requests that have happened.
             */
            this.hitsLast100 = new Uint8Array(100 / 4); // each hit is 2 bits

            /**
             * Tracks the hit rate for the last 1000 requests. If there
             * have been fewer than 1000 requests, the hit rate just
             * considers the requests that have happened.
             */
            this.hitsLast1000 = new Uint8Array(1000 / 4); // each hit is 2 bits

            /**
             * Tracks the hit rate for the last 10000 requests. If there
             * have been fewer than 10000 requests, the hit rate just
             * considers the requests that have happened.
             */
            this.hitsLast10000 = new Uint8Array(10000 / 4); // each hit is 2 bits

            /**
             * Tracks the hit rate for the last 100000 requests. If
             * there have been fewer than 100000 requests, the hit rate
             * just considers the requests that have happened.
             */
            this.hitsLast100000 = new Uint8Array(100000 / 4); // each hit is 2 bits

            /**
             * The number of calls that have passed through the
             * middleware since the server started.
             */
            this.callCount = 0;

            /**
             * The total number of hits since the server started
             */
            this.hitCount = 0;

            /**
             * The key from the last cache hit.  This is useful in
             * identifying which route these statistics apply to.
             */
            this.lastCacheHit = null;

            /**
             * The key from the last cache miss.  This is useful in
             * identifying which route these statistics apply to.
             */
            this.lastCacheMiss = null;

            /**
             * Return performance statistics
             * @returns {Object}
             */
            this.report = function () {
                return {
                    lastCacheHit: this.lastCacheHit,
                    lastCacheMiss: this.lastCacheMiss,
                    callCount: this.callCount,
                    hitCount: this.hitCount,
                    missCount: this.callCount - this.hitCount,
                    hitRate: this.callCount == 0 ? null : this.hitCount / this.callCount,
                    hitRateLast100: this.hitRate(this.hitsLast100),
                    hitRateLast1000: this.hitRate(this.hitsLast1000),
                    hitRateLast10000: this.hitRate(this.hitsLast10000),
                    hitRateLast100000: this.hitRate(this.hitsLast100000),
                };
            };

            /**
             * Computes a cache hit rate from an array of hits and
             * misses.
             * @param {Uint8Array} array An array representing hits and
             * misses.
             * @returns {?number} a number between 0 and 1, or null if
             * the array has no hits or misses
             */
            this.hitRate = function (array) {
                let hits = 0;
                let misses = 0;
                for (let i = 0; i < array.length; i++) {
                    let n8 = array[i];
                    for (let j = 0; j < 4; j++) {
                        switch (n8 & 3) {
                            case 1:
                                hits++;
                                break;
                            case 2:
                                misses++;
                                break;
                        }
                        n8 >>= 2;
                    }
                }
                let total = hits + misses;
                if (total == 0) {
                    return null;
                }
                return hits / total;
            };

            /**
             * Record a hit or miss in the given array.  It will be
             * recorded at a position determined by the current value of
             * the callCount variable.
             * @param {Uint8Array} array An array representing hits and
             * misses.
             * @param {boolean} hit true for a hit, false for a miss
             * Each element in the array is 8 bits, and encodes 4
             * hit/miss records. Each hit or miss is encoded as to bits
             * as follows: 00 means no hit or miss has been recorded in
             * these bits 01 encodes a hit 10 encodes a miss
             */
            this.recordHitInArray = function (array, hit) {
                let arrayIndex = ~~(this.callCount / 4) % array.length;
                let bitOffset = (this.callCount % 4) * 2; // 2 bits per record, 4 records per uint8 array element
                let clearMask = ~(3 << bitOffset);
                let record = (hit ? 1 : 2) << bitOffset;
                array[arrayIndex] = (array[arrayIndex] & clearMask) | record;
            };

            /**
             * Records the hit or miss in the tracking arrays and
             * increments the call count.
             * @param {boolean} hit true records a hit, false records a
             * miss
             */
            this.recordHit = function (hit) {
                this.recordHitInArray(this.hitsLast100, hit);
                this.recordHitInArray(this.hitsLast1000, hit);
                this.recordHitInArray(this.hitsLast10000, hit);
                this.recordHitInArray(this.hitsLast100000, hit);
                if (hit) {
                    this.hitCount++;
                }
                this.callCount++;
            };

            /**
             * Records a hit event, setting lastCacheMiss to the given key
             * @param {string} key The key that had the cache hit
             */
            this.hit = function (key) {
                this.recordHit(true);
                this.lastCacheHit = key;
            };

            /**
             * Records a miss event, setting lastCacheMiss to the given key
             * @param {string} key The key that had the cache miss
             */
            this.miss = function (key) {
                this.recordHit(false);
                this.lastCacheMiss = key;
            };
        }

        let perf = globalOptions.trackPerformance ? new CachePerformance() : new NOOPCachePerformance();

        performanceArray.push(perf);

        /**
         * Cache a request
         * @param {Object} req Express request object
         * @param {Object} res Express response object
         * @param {function} next Function to call next
         * @returns {any}
         */
        let cache = function (req, res, next) {
            function bypass() {
                debug("bypass detected, skipping cache.");
                return next();
            }

            // initial bypass chances
            if (!opt.enabled) {
                return bypass();
            }
            if (
                req.headers["x-apicache-bypass"] ||
        req.headers["x-apicache-force-fetch"] ||
        (opt.respectCacheControl && req.headers["cache-control"] == "no-cache")
            ) {
                return bypass();
            }

            // REMOVED IN 0.11.1 TO CORRECT MIDDLEWARE TOGGLE EXECUTE ORDER
            // if (typeof middlewareToggle === 'function') {
            //   if (!middlewareToggle(req, res)) return bypass()
            // } else if (middlewareToggle !== undefined && !middlewareToggle) {
            //   return bypass()
            // }

            // embed timer
            req.apicacheTimer = new Date();

            // In Express 4.x the url is ambigious based on where a router is mounted.  originalUrl will give the full Url
            let key = req.originalUrl || req.url;

            // Remove querystring from key if jsonp option is enabled
            if (opt.jsonp) {
                key = url.parse(key).pathname;
            }

            // add appendKey (either custom function or response path)
            if (typeof opt.appendKey === "function") {
                key += "$$appendKey=" + opt.appendKey(req, res);
            } else if (opt.appendKey.length > 0) {
                let appendKey = req;

                for (let i = 0; i < opt.appendKey.length; i++) {
                    appendKey = appendKey[opt.appendKey[i]];
                }
                key += "$$appendKey=" + appendKey;
            }

            // attempt cache hit
            let redis = opt.redisClient;
            let cached = !redis ? memCache.getValue(key) : null;

            // send if cache hit from memory-cache
            if (cached) {
                let elapsed = new Date() - req.apicacheTimer;
                debug("sending cached (memory-cache) version of", key, logDuration(elapsed));

                perf.hit(key);
                return sendCachedResponse(req, res, cached, middlewareToggle, next, duration);
            }

            // send if cache hit from redis
            if (redis && redis.connected) {
                try {
                    redis.hgetall(key, function (err, obj) {
                        if (!err && obj && obj.response) {
                            let elapsed = new Date() - req.apicacheTimer;
                            debug("sending cached (redis) version of", key, logDuration(elapsed));

                            perf.hit(key);
                            return sendCachedResponse(
                                req,
                                res,
                                JSON.parse(obj.response),
                                middlewareToggle,
                                next,
                                duration
                            );
                        } else {
                            perf.miss(key);
                            return makeResponseCacheable(
                                req,
                                res,
                                next,
                                key,
                                duration,
                                strDuration,
                                middlewareToggle
                            );
                        }
                    });
                } catch (err) {
                    // bypass redis on error
                    perf.miss(key);
                    return makeResponseCacheable(req, res, next, key, duration, strDuration, middlewareToggle);
                }
            } else {
                perf.miss(key);
                return makeResponseCacheable(req, res, next, key, duration, strDuration, middlewareToggle);
            }
        };

        cache.options = options;

        return cache;
    };

    /**
     * Process options
     * @param {Object} options 
     * @returns {Object}
     */
    this.options = function (options) {
        if (options) {
            Object.assign(globalOptions, options);
            syncOptions();

            if ("defaultDuration" in options) {
                // Convert the default duration to a number in milliseconds (if needed)
                globalOptions.defaultDuration = parseDuration(globalOptions.defaultDuration, 3600000);
            }

            if (globalOptions.trackPerformance) {
                debug("WARNING: using trackPerformance flag can cause high memory usage!");
            }

            return this;
        } else {
            return globalOptions;
        }
    };

    /** Reset the index */
    this.resetIndex = function () {
        index = {
            all: [],
            groups: {},
        };
    };

    /**
     * Create a new instance of ApiCache
     * @param {Object} config Config to pass
     * @returns {ApiCache}
     */
    this.newInstance = function (config) {
        let instance = new ApiCache();

        if (config) {
            instance.options(config);
        }

        return instance;
    };

    /** Clone this instance */
    this.clone = function () {
        return this.newInstance(this.options());
    };

    // initialize index
    this.resetIndex();
}

module.exports = new ApiCache();
